DHMemberPortal: make signup scaffolding writes best-effort (#283 bandaid)#284
Merged
Conversation
Issue #283. Once add_member succeeds, the seven secondary JSONB writes (connections/status/forms/notes/access/authorizations/extras) run in a loop with per-step try/except so a transient failure no longer aborts the redirect to /signup/payment. Also bumps the 10s timeout to 20s on the seven secondary helpers in dhservices.py for cheap insurance against slow DHService responses. A failed scaffolding step leaves the corresponding JSONB column null; admin tooling can address that later. The user still reaches Stripe, which is the bandaid's only goal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dict.get('key', '') returns '' only when the key is missing — when the
key is present with a None value (which happens when v_member_info
materializes a NULL status column as {membership_status: null, ...}),
.get returns None and the following .lower() crashes with AttributeError.
The signup scaffolding bandaid in c77499b makes this state reachable in
production: when update_member_status fails, Stripe can later write its
stripe_* keys to status without anyone setting membership_status, leaving
that key None.
Swap to ((dict or {}).get('membership_status') or '').lower() at four
sites in DHMemberPortal (B2C authorized, _get_authenticated_member_info,
RFID gate in member_update_profile, dev_login_select) and the
equivalent in ST2DH update_membership (which was using raw [] indexing,
so also vulnerable to KeyError).
Verified live against dev DB row 51 (status column NULL): dashboard,
profile, keys, auths, storage pages all render. RFID gate refuses
update with status='' instead of 500. Pending/active/suspended/banned
flows still behave correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The unconditional session.clear() in signup_start logs out any signed-in
user who visits /signup (a GET, so trivially weaponizable as a logout
link via attacker-crafted URL) and wipes the flash queue, swallowing the
"A member with this email already exists" message when signup_submit
redirects back to signup_start. Replace with a narrow
session.pop('signup_email', None) — the only key the signup flow writes.
Addresses Findings 5 and 14 from the 2026-05-20 signup-flow sweep.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
signup_submit had no server-side check for duplicate usernames — the /api/check-username AJAX call on the form was only advisory, so a direct POST (or a normal submit after a races on the AJAX call) could create a second member row with a colliding active_directory_username. Downstream this collides in AD/B2C when the dispatcher tries to provision the account. Add a case-insensitive gate using the existing dhservices.is_username_taken helper (which calls is_username_available in DHService, LOWER() = LOWER() per #252). Closes Findings 7-username and 9 from the 2026-05-20 signup-flow sweep. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tity Two simultaneous signup_submit POSTs for the same primary email used to both call get_member_id_from_email (opens its own connection, sees no match), then both INSERT in separate connections — producing two member rows with the same primary email. Downstream scaffolding writes that re-resolve by email then silently overwrote the loser's data (Finding 8 in the 2026-05-20 signup-flow sweep). Move the email lookup inside the same transaction as the INSERT/UPDATE and take a pg_advisory_xact_lock keyed on hashtext(LOWER(email)) before the lookup. Concurrent same-email signups serialize on the lock; the loser sees the winner's row and falls through to the UPDATE branch instead of inserting a duplicate. Different-email signups don't contend. No schema change; the standard caller-supplied member_id path is untouched. A real UNIQUE constraint on primary email is the right long-term fix but requires a schema migration and per-row dedup work first; that's a separate follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous Finding 5+14 commit (67c3195) scoped the session reset so flash messages survive the signup_submit → signup_start redirect, but signup_email.html didn't actually render flashes — so the preserved "A member with this email already exists" message still wouldn't show on the resubmit-after-back path. Add the same get_flashed_messages block used by _dashboard_base.html so the flash actually surfaces. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced May 26, 2026
Open
Contributor
Author
|
@tachoknight Throwing this to you to give it a once over please. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bandaid for #283 plus four bundled fixes for signup-flow issues discovered during follow-up ad-hoc testing (see
.claude/notes/2026-05-20-signup-flow-adhoc-findings.md).Original bandaid (#283)
/signup/submitwas non-atomic — 8 sequential HTTP calls inside onetry/except— so any transient failure afteradd_membersucceeded would abort the redirect to/signup/paymentand strand the user with a half-created row.get_access_token,get_member_id,add_member) still bounces the user to/signupon failure.add_memberreturns a member_id, the seven secondary JSONB writes run in a for-loop with per-steptry/except. Each step logs success at INFO and failure at ERROR; the loop never throws./signup/paymentonce the row exists.dhservices.pyfromtimeout=10→timeout=20.Bundled fixes (signup-flow hardening, all caused-or-worsened by the bandaid)
a64fccestatus.membership_statusis None((dict or {}).get('key') or '').lower()pattern across 5 portal/ST2DH sites67c3195GET /signupdidsession.clear(), logging out any logged-in user and silently eating the "already exists" flash on retry657e93dget_flashed_messagesonsignup_email.htmlso the flash actually surfaces2fdbcd9signup_submitaccepted duplicateactive_directory_username, including the concurrent race variantis_username_takencheck beforeadd_member; flash + redirect on collision72f8abeadd_update_identitywraps the email-lookup + INSERT in a transactional advisory lock keyed on the lowercased emailOut of scope (separate follow-ups)
Filed as #292 (enumeration + rate-limit), #293 (server-side validation), #294 (
/signup/paymentarbitrary email), #295 (XSS render audit), #296 (logged-in user can create orphan members). None are bandaid-related.Tests completed
All run live on dev with the rebuilt
dh_member_portal+dh_service+dh_st2dhcontainers.Bandaid core
/signup/payment; Stripe pricing-table iframe renders both tiersCreated new member with ID: N+ 7×Signup member N: <label> initializedraisefault injection:forms+notesforced to fail mid-loop — user still reaches/signup/payment, those two columns are null, other 5 populated, ERROR log lines surfaced/signup/payment, exactly the one targeted column is null and the other six are populated/signup, no row createdFinding 10 —
membership_statusNone guard (a64fcce)status = NULL: dev-login +/dashboardrenders withoutAttributeError: 'NoneType' object has no attribute 'lower'/dashboard,/dashboard/profile,/dashboard/keys,/dashboard/auths,/dashboard/storage,/dashboard/floof), every page HTTP 200, zeroAttributeErrorin logs/dashboard*URLs to/dashboard/locked, zeroAttributeErrorin logs((dict or {}).get(key) or '').lower()expression against all 4 input shapes (status=None, status[ms]=None, missing key, normal value)code/DHMemberPortal/app.py+ 1 incode/external/ST2DH/app.pyFinding 5 + 14 — narrow session reset + flash rendering (
67c3195,657e93d)/signup→/dashboardstill HTTP 200 (was 302 →/)/signupno longer logs them out (Finding 5 confirmed end-to-end)/signup→ flash "A member with this email already exists" now renders onsignup_email.html/signup/submitwith their OWN email → HTTP 302 →/signup(correctly routed through the "already exists" path; zero duplicate rows created)Finding 7-username + 9 — server-side username uniqueness (
2fdbcd9)username=alovelace(exact case match against seed): rejected with "That username is already taken" flash, zero DB rows createdusername=ALOVELACE(case-insensitive match): rejected the same way, zero rowsadd_memberFinding 8 — advisory lock on same-email INSERT (
72f8abe)/signup/payment, 14 routed to/signup"already exists". Zero deadlocks, zero lock-wait errors, zero exceptions in DHService or portal logs. Total completion: 8.4s (~550ms per serialized lock acquisition).Test plan
Pre-merge — held until external prereqs exist
stripe.Customer.retrieve(customer_id)against the live Stripe API to look up the customer's email; that fails immediately in a credential-less dev environment. Worth running once a dev Stripe test account is configured.Post-merge / production
status->>'stripe_product_id'lands as expected via the ST2DH webhook for a real new signupupdate_member_accessfrom the dashboard Keys tab still saves within the 20s ceilingKnown trade-offs
update_member_accessis also used by the dashboard Keys page profile save. The 20s timeout now applies there too. The worst case is a 20s synchronous Flask wait on a real DHService timeout (previously 10s); normal writes are sub-second.🤖 Generated with Claude Code